Skip to content

fix(semantic): bootstrap-complete archives a reopened replayed turn without duplicating it - #1391

Merged
Juliusolsson05 merged 6 commits into
mainfrom
fix/semantic-history-bootstrap-dedupe
Sep 27, 2026
Merged

Juliusolsson05 merged 6 commits into
mainfrom
fix/semantic-history-bootstrap-dedupe

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Fixes #1290.

Plan: docs/plans/2026-09-27-semantic-history-bootstrap-dedupe.md.

Problem

The bootstrap-complete reconciler in useIpcSubscriptions.ts archived a still-open replayed turn with a raw [...history, row] append. A replayed turn T that was already in history could reopen as currentTurn, and the ledger hides that copy while T is live. Bootstrap-complete then appended T a second time. The result was two history rows with one turnId, repeated sem:T:i candidate ids, and duplicate semantic-block:T:i React keys.

What merges

The reconciler archives through a new archiveReplayedTurn helper in session-runtime/semantic/helpers.ts:

  • T not archived yet: the helper appends it (via appendSemanticHistory).
  • T already archived: the helper stores ONE row, a field-wise monotonic merge of the archived copy and the replay copy:
    • strings: the replay's value wins only when it extends the archived value (it starts with the archived value and is longer). Otherwise the archived value stays. This also keeps an archived status: 'completed' against a reopened, longer 'in_progress'.
    • plain objects (the turn, blocks, each block, lookups, usage): merged key by key.
    • arrays of primitives (blockOrder, the lookup id lists): unioned.
    • lifecycle fields take the MORE advanced value from either copy: status ranks pending < in_progress < terminal (two different terminal values, or an unknown value, keep the archived one); messagePhase ranks commentary < final_answer; booleans OR (finalized, inputJsonValid only ever become true). This protects a completed archive from a reopened replay AND lets a replay complete a block or tool the archive saw pending (review a, round 4).
    • a tool result is one unit (review a round 5, steering q95): resultContent, resultIsError, resultAt and the tool's lookup state (toolCallsById[id], and its membership in resolvedToolUseIds / erroredToolUseIds) all come from the copy with the LATER resultAt, and from the replay on a tie. Field by field, an archived error followed by a corrected result painted the success red, because the live reducer lets a later result clear the error, so no single result field is monotonic.
    • derived lists: task.inProgressToolUseIds is recomputed after the merge (the union minus every resolved/errored id). activeToolNames keeps only names both copies agree on.
    • everything else (numbers, and arrays of objects such as the task checklist): the archived value is kept.

Invariant: every archived string survives at its path, either unchanged or extended, and every archived block index and id stays. No lifecycle field moves backwards, and none is held back when either copy has progressed. The ledger paints only from these fields, so a field visible before bootstrap-complete cannot vanish. Content the replay added, such as a new block or longer text, is kept too.

Why a merge and not a proxy: reviewer A rejected three "does the replay cover the archive?" proxies in rounds 1–3:

  • turn-text length missed empty blocks;
  • block count missed an empty block at the same index;
  • a content-size total missed reasoningSummary, and let unpainted turn text outweigh a missing answer block.

A merge doesn't have to judge which copy is richer.

Scope: live fold paths are unchanged. There, a newer copy of a turn stays authoritative (appendSemanticHistory replace).

Tests

  • archiveReplayedTurn.test.ts: 15 cases. Each feeds the helper's output through a real renderer function (createLedgerInputAdapter() for painted text, groupSemanticActivity for tool activity). The cases:
    • both round-3 sequences from reviewer A (Codex reasoningSummary, and longer turn text vs an empty answer block);
    • rounds 1 and 2;
    • mixed progress: archived block 0 plus a new replay block 1;
    • an archived block the replay has not re-emitted;
    • the status-length trap;
    • replay-extends-archive;
    • round 5: reviewer a's corrected-result sequence (error at t=2, success at t=3, including the error→completed lookup status), the archived result being later, and a tie;
    • round 4: forward completion (Codex status, Claude finalized), and a Read tool the replay resolved, plus the reverse, checked through the real groupSemanticActivity (not running) and the recomputed in-progress lists.
  • Fail-first: at f7b65430, 4 of the round-3 cases fail (both round-3 sequences, mixed progress, status). At f6cae4ba, the 4 round-4 cases fail. At 8aa9ab02, the 3 round-5 cases fail.
  • Mutations, each killed:
    • result unit: tie goes to the archive, lookup not taken, errored/resolved membership not taken, resultIsError not taken;
    • no boolean OR; status/phase rank off, or replay always wins; no derived-task recompute; no resolved filter; active-name union; no key passed to the rank rule;
    • "longer string wins";
    • "replay wins";
    • no union;
    • no per-key merge;
    • plain replace.
  • bootstrapHistoryDedupe.renderer.test.tsx: drives the real useIpcSubscriptions through the fake session feed. It checks two things:
    • one turn-T row after bootstrap-complete (before the fix: ['turn-T', 'turn-T']);
    • archived text survives an empty reopened copy.
  • Limit: no recorded bootstrap-overlap sequence exists in the fixture corpus. The turn states are built from the event shapes the reviewer traced (a repeated turn_started/block_started with no deltas yet). Nothing here measures how often this happens.
  • Gates: npx tsc -b + src/renderer/src/workspace/hook/ipc + src/renderer/src/session-runtime/semantic + src/renderer/src/features/feed + src/renderer/src/rendering on 39c712b6: 58 files, 419 tests.

🤖 Generated with Claude Code

…ithout duplicating it

#1290: the bootstrap-complete reconciler archived a still-open replayed
turn with a raw append while every other path replaces by turnId, so a
replayed turn already in history that reopened as currentTurn was held
twice (duplicate sem:T:i candidates and semantic-block:T:i keys). It now
uses appendSemanticHistory. Red before on the real subscription hub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05 Juliusolsson05 added type:bug Something works wrong class:C8-rendering Rendering order/duplicates sev:P3 Minor labels Sep 27, 2026
Juliusolsson05 and others added 5 commits September 27, 2026 02:15
…n at bootstrap-complete

#1391 review a: a replayed turn_started reopens T empty; if replay quiets
before its content re-arrives, replacing by turnId swapped the full archived
T for the empty copy and the answer vanished from the feed.
archiveReplayedTurn keeps the archived row unless the replay copy renders at
least as much (text and blocks); still one row per turnId. Live fold paths
keep replace-by-id (a newer live copy is authoritative). Text and block rules
each pinned; red before on the reviewer's sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y copy replaces the archived turn

#1391 review a, round 2: a replay can reopen the same block EMPTY (block_started
then quiet before text_delta), so equal block counts still let an empty block
displace the archived answer. archiveReplayedTurn now compares renderable
size: turn text plus each block's text, thinking, tool input and tool
result. Each term, the equal-replaces rule and the reviewer's probe are
pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…field (#1391 q93)

Three size proxies for 'the replay covers the archive' failed review. The
bootstrap path now merges: strings take the replay only when it extends
the archived value, objects merge per key, id lists union. Tests go through
the real ledger adapter, including both round-3 sequences and a mixed-progress case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eplay merge (#1391 review a round 4)

Keeping archived lifecycle values blocked a replay that completed a block or
tool the archive saw pending. status/messagePhase now rank, booleans OR, and
the derived in-progress tool lists are recomputed after the merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 review a round 5, q95)

resultContent/resultIsError/resultAt and the tool's lookup state now come
together from the copy with the later resultAt (replay on a tie). Merged
field by field, an archived error followed by a corrected result painted the
success red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Review disposition (head 39c712b6). Round 1: b and c MERGE-READY; a FIX-BEFORE-MERGE, and a's findings drove rounds 2–5. Round 6 was capped by steering q95, and B6 verified the final head by hand (manager-verify-a.md): MERGE-READY.

Fixed

  • a1 (round 1): bootstrap-complete appended a replayed turn that was already archived, which duplicated its React keys. The replayed turn is now archived through one helper, archiveReplayedTurn.
  • a2–a3 (rounds 2–3): three "does the replay cover the archive?" size proxies each lost content (empty blocks, equal block counts, reasoningSummary, unpainted turn text). They were replaced, per steering q93, by a field-wise monotonic merge: a string is replaced only when the replay extends it, objects merge per key, and id lists are unioned. Tests run through the real ledger adapter.
  • a4 (round 4): keeping archived lifecycle values blocked a replay that completed a block or tool. status and messagePhase are now ranked, booleans OR, and the in-progress lists are recomputed.
  • a5 (round 5): OR on resultIsError painted a corrected result red. Per steering q95, a tool result (content, error flag, time, and lookup state) is now ONE unit, taken from the copy with the later resultAt, and from the replay on a tie.

Residuals (stated, not blocking)

  • Block-index match: the result unit is matched by block index between the two copies of the same turnId. That relies on a replay of one turn keeping its block indices, which is what the reducer does for a reopened replay.
  • No recorded bootstrap-overlap sequence exists. The turn states are built from the reducer's event shapes, and nothing here measures how often this happens.
  • Archived values kept: numbers (endedAt) and object arrays (todos) keep the archived value. No wrong output was found from them.

🤖 Generated with Claude Code

@Juliusolsson05
Juliusolsson05 merged commit 24122ff into main Sep 27, 2026
2 checks passed
Juliusolsson05 added a commit that referenced this pull request Sep 27, 2026
@Juliusolsson05
Juliusolsson05 deleted the fix/semantic-history-bootstrap-dedupe branch September 27, 2026 12:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

class:C8-rendering Rendering order/duplicates sev:P3 Minor type:bug Something works wrong

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Semantic history can hold a replayed turn twice (bootstrap-complete appends raw)

1 participant